Skip to content

KSES: Reimplement with Tag Processor - #13271

Open
dmsnell wants to merge 27 commits into
WordPress:trunkfrom
dmsnell:kses/dual-with-tag-processor
Open

dmsnell wants to merge 27 commits into
WordPress:trunkfrom
dmsnell:kses/dual-with-tag-processor

Conversation

@dmsnell

@dmsnell dmsnell commented Aug 25, 2026

Copy link
Copy Markdown
Member

Trac ticket: Core-65984

Description

Rewrites wp_kses() to rely on the HTML API for structural and reliable application of sanitization rules, normalizing the output for improved downstream parsing.

Notables

  • A new filter wp_kses_force_legacy_parser provides the choice of whether to use this new parser or stick with the legacy code.

Todo

Merge after #13273, which accounts for three of the failing tests.

  • self-closing non-HTML elements
  • [~] remove opening tag when required attributes are missing, and closing tag
    • while this would be a nice enhancement it’s going to be left out of this work to preserve existing behaviors. with the HTML Processor powering wp_kses(), it’s possible to simply wait until an opened element is closed based on depth, and skip that closing element if it exists.
  • replace C0 controls
    • replace C0 controls with their escapes, rather than stripping them away
    • replace C0 controls in attribute values?
    • original commit removing C0 controls is c7fd8c7
    • C0 controls are left in-place to prevent problems with creating new syntax through their removal
  • [~] handle incomplete parsing, including closing all open elements
    • plenty of existing code in Core calls wp_kses() with intentionally-incomplete input, for example, a wrapper opening tag with part of the content, separately from the closer. closing open elements does a good job of isolating content, but legacy behaviors depend too much on the more procedural use of wp_kses() so isolation cannot be reasonably added without mangling websites.
  • if SVG or MATH are not allowed, the entire element should disappear
  • remove default pre_kses filters but then call pre_kses

Notes

  • Branch tip with HTML Processor bdbae3a

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@dmsnell
dmsnell force-pushed the kses/dual-with-tag-processor branch 22 times, most recently from c784058 to fdc5e96 Compare August 27, 2026 17:00
@dmsnell
dmsnell force-pushed the kses/dual-with-tag-processor branch 5 times, most recently from bd15b3e to 38470b2 Compare August 28, 2026 04:31
Comment thread src/wp-includes/kses.php Outdated
Comment on lines +1447 to +1461
$block_type = str_starts_with( $block_type, 'core/' )
? substr( $block_type, /* 'core/' */ 5 )
: $block_type;

$filtered_attributes = filter_block_kses_value(
$original_attributes,
$this->allowed_html,
$this->allowed_protocols,
array( 'blockName' => $block_type )
);

if ( $original_attributes !== $filtered_attributes ) {
$serialized_attributes = serialize_block_attributes( $filtered_attributes );
$voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : '';
$text = " wp:{$block_type} {$serialized_attributes} {$voider}";

@sirreal sirreal Sep 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The full normalized version of $block_type (including core/ prefix) must happen after filter_block_kses_value because that expects to be passed the complete block type including the core/ prefix. See:

if ( isset( $block_context['blockName'] ) && 'core/template-part' === $block_context['blockName'] ) {
$filtered_value = filter_block_core_template_part_attributes( $filtered_value, $filtered_key, $allowed_html );
}

It's essential to remove the core/ implicit prefix for the serialization, but not before:

Suggested change
$block_type = str_starts_with( $block_type, 'core/' )
? substr( $block_type, /* 'core/' */ 5 )
: $block_type;
$filtered_attributes = filter_block_kses_value(
$original_attributes,
$this->allowed_html,
$this->allowed_protocols,
array( 'blockName' => $block_type )
);
if ( $original_attributes !== $filtered_attributes ) {
$serialized_attributes = serialize_block_attributes( $filtered_attributes );
$voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : '';
$text = " wp:{$block_type} {$serialized_attributes} {$voider}";
$filtered_attributes = filter_block_kses_value(
$original_attributes,
$this->allowed_html,
$this->allowed_protocols,
array( 'blockName' => $block_type )
);
if ( $original_attributes !== $filtered_attributes ) {
$serialized_attributes = serialize_block_attributes( $filtered_attributes );
$block_type = str_starts_with( $block_type, 'core/' )
? substr( $block_type, /* 'core/' */ 5 )
: $block_type;
$voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : '';
$text = " wp:{$block_type} {$serialized_attributes} {$voider}";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already saw that but hadn’t pushed it. thank you!

@sirreal

sirreal commented Sep 18, 2026

Copy link
Copy Markdown
Member

This should inject newlines into some HTML elements

Do you think this should be an easy thing to resolve? Just ensure that the modifiable text always starts with a newline? Or is the problem that we’re pushing out the decoded text and what we should be doing is building a custom <pre>#text</pre> and then chopping off the <pre> and </pre>?

A few elements ignore a first newline: TEXTAREA, PRE, LISTING. TEXTAREA is already covered by set_modifiable_html():

/*
* HTML ignores a single leading newline in this context. If a leading newline
* is intended, preserve it by adding an extra newline.
*/
if (
'TEXTAREA' === $this->get_tag() &&
1 === strspn( $plaintext_content, "\n\r", 0, 1 )
) {
$plaintext_content = "\n{$plaintext_content}";
}

It should be covered on get_modifiable_text() for these elements, so the leading newline be stripped:

/*
* Skip the first line feed after LISTING, PRE, and TEXTAREA opening tags.
*
* Note that this first newline may come in the form of a character
* reference, such as `&#x0a;`, and so it's important to perform
* this transformation only after decoding the raw text content.
*/
if (
( "\n" === ( $decoded[0] ?? '' ) ) &&
( ( $this->skip_newline_at === $this->token_starts_at && '#text' === $tag_name ) || 'TEXTAREA' === $tag_name )
) {
$decoded = substr( $decoded, 1 );
}

I think the best thing to do here for PRE and LISTING is to always inject a leading newline after their open tag, something like this:

$output .= $tag_maker->get_updated_html();
if ( 'html' === $namespace && 'PRE' === $tag_name || 'LISTING' === $tag_name ) {
  $output .= "\n";
}

I shared a potential test for this, it could probably be added to the idempotency test data set:

public function test_pre_leading_newline_is_preserved_and_idempotent(): void {
    $input = "<pre>\n\ncode</pre>";
    $once  = wp_kses( $input, 'post' );
    $twice = wp_kses( $once, 'post' );
    $this->assertSame( $once, $twice, 'wp_kses() must be idempotent for <pre> content.' );
    $this->assertSame( $input, $once, 'wp_kses() must not consume newlines inside <pre>.' );
}

@sirreal

sirreal commented Sep 18, 2026

Copy link
Copy Markdown
Member

There are some behavioral changes around unclosed blocks. Before, KSES would close them (either as void or with a closing delimiter):

Input Before After
<!-- wp:a --> <!-- wp:a /--> <!-- wp:a -->
<!-- wp:a -->in <!-- wp:a -->in<!-- /wp:a --> <!-- wp:a -->in

dmsnell and others added 27 commits September 23, 2026 19:22
Co-Authored-By: Jon Surrell <jonsurrel@git.wordpress.org>
Co-Authored-By: Jon Surrell <jonsurrell@git.wordpress.org>
Notably, contents of SCRIPT elements _should not_ be extracted and
rendered as HTML text nodes. These are SCRIPT contents, and should be
hidden from the page.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants